iT邦幫忙

2024 iThome 鐵人賽

DAY 22
0
自我挑戰組

30 天 vueuse 原始碼閱讀與實作系列 第 22 篇

[Day 22] useScroll - scrolling

  • 分享至 

  • xImage
  •  

官方 Demo:https://vueuse.org/core/useScroll/#usescroll

昨天看了 Demo 中的 Top Arrived、Right Arrived、Bottom Arrived、Left Arrived 是怎麼來的,接下來繼續看 Demo 中 Scrolling Up、Scrolling Right、Scrolling Down、Scrolling Left、isScrolling 這五個 Boolean 的計算方式。

directions

Scrolling Up、Scrolling Right、Scrolling Down、Scrolling Left 這四個 Boolean 是來自於 directions 這個 reactive 物件。

以下只保留跟這次主題 directions 有關的程式碼:

// src/compositions/useScroll.js

export function useScroll(element, options = {}) {
 const {
    throttle = 0,
    idle = 200,
    onStop = noop,
    eventListenerOptions = {
      capture: false,
      passive: true,
    },
  } = options
  
  const isScrolling = ref(false)
  
  const directions = reactive({
    left: false,
    right: false,
    top: false,
    bottom: false,
  })
  
  const onScrollEnd = (e) => {
    // dedupe if support native scrollend event
    if (!isScrolling.value)
      return

    isScrolling.value = false
    directions.left = false
    directions.right = false
    directions.top = false
    directions.bottom = false
    onStop(e)
  }
  
  const setArrivedState = (target) => {
    if (!window)
      return

    const el = (
      (target)?.document?.documentElement
      || (target)?.documentElement
      || unrefElement(target)
    )

    const scrollLeft = el.scrollLeft

    directions.left = scrollLeft < internalX.value
    directions.right = scrollLeft > internalX.value
    
    internalX.value = scrollLeft
    
    // ...略
    
    let scrollTop = el.scrollTop

    directions.top = scrollTop < internalY.value
    directions.bottom = scrollTop > internalY.value
    
    internalY.value = scrollTop
  }
  
  const onScrollHandler = (e) => {
    // ...略
    const eventTarget = (
      (e.target).documentElement ?? e.target

    isScrolling.value = true
    setArrivedState(eventTarget)
    onScrollEndDebounced(e)
  }
  
  useEventListener(
    element,
    'scroll',
    onScrollHandler,
    eventListenerOptions,
  )
  
  useEventListener(
    element,
    'scrollend',
    onScrollEnd,
    eventListenerOptions,
  )
  
  return {
    isScrolling,
    directions,
  }
}

可以看到主要邏輯跟前兩天一樣,也是放在 setArrivedState function:

directions.left = scrollLeft < internalX.value
directions.right = scrollLeft > internalX.value

internalX.value = scrollLeft

這邊先拿 directions.left 來看,向左滾動的時候,scrollLeft 會越來越小,internalX 也會跟著變小,可以在 internalX.value 更新之前,判斷 scrollLeft 比還沒更新的 internalX 還小,代表正在向左滾動。directions.right 也是一樣的邏輯。

接下來要考慮的就是,停止向左滾動的時候,directions.left 必須被設定成 false,這邊有用到 scrollend 這個事件偵聽,當 scrollend 觸發時,會執行 onScrollEnd function:

const onScrollEnd = (e) => {
    // dedupe if support native scrollend event
    if (!isScrolling.value)
      return

    isScrolling.value = false
    directions.left = false
    directions.right = false
    directions.top = false
    directions.bottom = false
    // 上層傳入的參數,在 onScrollEnd 執行時會呼叫
    onStop(e)
}

滿直覺的,就是在 scrollend 的時候把所有 scrolling 相關 boolean 都設定成 fasle。
需要注意的是這段:

// dedupe if support native scrollend event
if (!isScrolling.value)
  return

如果回到最上面程式碼看的話,會看到 onScrollEnd function 其實有兩個地方會呼叫到他,一個是我們現在提到的 scrollend event 被觸發的時候,另一個則是在 onScrollHandler 內部呼叫 onScrollEndDebounced(e)。先來說為什麼都已經透過監聽 scrollend 事件的觸發,來呼叫 onScrollEnd 了,還需要 onScrollEndDebounced?

因為 safari 目前還沒有支援 scrollend event。來看一下 onScrollEndDebounced:

const onScrollEndDebounced = useDebounceFn(onScrollEnd, throttle + idle)

throttle、idle 都是上層傳入的參數,idle 預設為 200ms,throttle 預設為 0,當我們滾動停止過 200ms 後,onScrollEnd 就會被執行,如果在有支援 scrollend event 的瀏覽器,會在這個 200ms 之前就已經執行過 onScrollEnd function,isScrolling.value 也已經被設定為 false 了,所以才需要有 if (!isScrolling.value) return 這層判斷,避免在 onScrollEndDebounced 這邊又重複執行一次。

safari 支援度:https://caniuse.com/?search=scrollEnd

setArrivedState 的執行時機

目前講到的 setArrivedState 的執行時機都是 scroll event 被觸發的時候,接下來要補上其他需要執行 setArrivedState 的情境,一個是 bug、另一個是 feature。

arrivedState bug

先講 bug,來看一下 arrivedState 的預設值:

const arrivedState = reactive({
    left: true,
    right: false,
    top: true,
    bottom: false,
})

預設 right、bottom 都是 false,在 element 可以滾動的情境這樣沒什麼問題,但如果 element 不需要滾動呢?在不需要滾動的情境(內層寬高比外層設定 overflow-scroll 的寬高還小),arrivedState 中的上右下左應該都要為 true,但因為剛剛提到我們的核心計算 setArrivedState 在 scoll 的時候才會執行,所以組件掛載完成後,right、bottom 依舊是 false。
知道問題在哪後,解法應該也很直覺,就是在 mounted 的時候執行一次 setArrivedState:

// src/compositions/useScroll.js
tryOnMounted(() => {
    try {
      const _element = toValue(element)
      if (!_element)
        return
      setArrivedState(_element)
    }
    catch (e) {
      onError(e)
    }
})

onError 是 useScroll 的一個 option 參數,預設為 onError = (e) => { console.error(e) },接著來看一下 tryOnMounted 的程式碼:

// src/utils/shared.js
// import { getCurrentInstance, nextTick, onMounted } from 'vue'

export function getLifeCycleTarget(target) {
  return target || getCurrentInstance()
}

export function tryOnMounted(fn, sync = true, target) {
  const instance = getLifeCycleTarget()
  if (instance)
    onMounted(fn, target)
  else if (sync)
    fn()
  else
    nextTick(fn)
}

這邊有用到 Day 14 有提到過的 getCurrentInstance,詳細可以參考那篇,以目前這邊的流程,會走到 onMounted(fn, target) 這行,target 會是 undefined,在 Day 14 也有提到 Vue onMounted 的運作流程,以及在第二個參數(target)沒有值的時候,是怎麼拿到當前組件實例的,這部分就先略過。這裡可以先簡單想成在組件 mounted 的時候,執行了 setArrivedState 來更新正確的 arrivedState 狀態。

return measure

看原始碼會發現 useScroll 的 return 物件中有一個 measure 屬性:

return {
    // ...略
    measure() {
      const _element = toValue(element)

      if (window && _element)
        setArrivedState(_element)
    },
}

現在會看到 useScroll API 是因為一開始想看 useInfiniteScroll API,useInfiniteScroll API 中有用到 useScroll,造就了現在的惡夢(?)

現在我們知道 setArrivedState 會在 scroll 或是 monted 的時候被執行,這個 measure 是讓上層可以在這兩個之外的情境來執行 setArrivedState,取得最新狀態,像是 useInfiniteScroll 中的其中一段:

watch(
    () => [state.arrivedState[direction], isElementVisible.value],
    checkAndLoad,
    { immediate: true },
)

這個 checkAndLoad 裡面有執行到 setArrivedState,也就是說在 isElementVisible.value 變化的時候,需要執行 setArrivedState,至於詳細目的是什麼?當然是之後再說 XD

GitHub:https://github.com/RhinoLee/30days_vue/pull/21/files


useScroll 這條牙膏到今天第三天終於擠完了(?)看完覺得能有 vueuse 這樣的工具可以用滿幸福的,裡面有一些例外處理或是瀏覽器不同造成的差異都有考慮到,也可以從這次看原始碼的經驗,多注意之後在專案中設計共用 API 需要考慮的項目與細節。

今天就到這邊告一段落,明天開始會繼續看 useElementVisibility API~


上一篇
[Day 21] useScroll - arrivedState
下一篇
[Day 23] useIntersectionObserver
系列文
30 天 vueuse 原始碼閱讀與實作 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言